Seismic search optimizations - #20
Conversation
17417f6 to
ce6a599
Compare
| // smaller (1 bit/doc, ~1.1 MB at 8.8M docs) so far fewer distinct cache lines | ||
| // are touched, while the touched-word list lets a new query clear only the | ||
| // words it actually dirtied (sparse O(visited) reset, not O(n)). | ||
| class VisitedSet { |
There was a problem hiding this comment.
Thanks for the detailed estimation on the 8.8M dataset. Do you think the current visited set can apply to all dataset scales? I'm worrying the bit set size from the super large dataset
There was a problem hiding this comment.
It scales with data size, so 11 MB for 88M, and we've benchmark at most 45M docs for a single host, seems OK to me, but I'll benchmark on 45M later.
There was a problem hiding this comment.
I understand users rarely use more than 45M in a single segment, but there is no guarantee that the segment size will always be smaller than a certain threshold. How about also keeping the hash set solution and switch the set solution based on the dataset size? For example, dataset size <= 100M will use the bit set.
There was a problem hiding this comment.
When we are not even testing a larger dataset, why should we set a presumable hard coded number to branch two logic.
There was a problem hiding this comment.
We're not testing a larger dataset, but anything could happen from the user side where they perform force merge operation so that the segment contains soo many documents that the visited set consumes too much memory.
I also don't feel like the hard coded number solution, but memory issue always exists. Even if the C++ solution is working on non-JVM memory. The users can still limit their circuit breaker setting, making it not possible to use the bitset solution.
zirui-song-18
left a comment
There was a problem hiding this comment.
In addition, I'm worried that the huge pages might introduce P99 spikes via NUMA auto-migration. Would you mind testing the P50 P90 P99 data before & after the optimization?
| void new_query() { | ||
| for (const size_t w : touched_) { | ||
| bits_[w] = 0; | ||
| } | ||
| touched_.clear(); | ||
| } |
There was a problem hiding this comment.
The new_query() sparse-reset is the most fragile point in this PR — it clears only touched_ words, relying on "a word is in touched_ iff it has a set bit" (maintained solely by if (prev==0) touched_.push_back(w)). I don't see a VisitedSet test (the only test change is transposed_scoring_matches_reference). Please add: (a) dedup within a query; (b) full reset across new_query; (c) two ids in the same 64-bit word push touched_ once and both clear; (d) resize() clears touched_; (e) boundaries 63/64/n-1. A regression here silently corrupts recall and is invisible to perf benchmarks.
| void new_query() { | ||
| for (const size_t w : touched_) { | ||
| bits_[w] = 0; | ||
| } | ||
| touched_.clear(); | ||
| } |
There was a problem hiding this comment.
When k is larger than 100, new_query will take longer time clearing the touched. Can we have different strategy for the set? For example, when k <= 100, use the current one. If k > 1000, use the hash set?
There was a problem hiding this comment.
It's actually related to the doc count we visited, not the value of k, right?
There was a problem hiding this comment.
Larger k will visit more documents
There was a problem hiding this comment.
Oh, you are right about k visiting more documents, but the iteration of touched is quite cache friendly by sequentially traverse, right? I don't think it makes quite sense to set a hard-coded number k to introduce branching logic make less readible code and unpredictable branches.
There was a problem hiding this comment.
For k = 10, VisitedSet only boost is 1.10×. For k = 100, VisitedSet only boost is 1.02×. I am afraid that for a larger value of k, the visited set boost will have negative impact.
Inter-process ablation on the 8.8M-doc corpus shows ~1.4x for float Seismic and ~1.06x for the 8-bit SQ variant from: - VisitedSet: bitset dedup with sparse per-query reset, replacing the absl::flat_hash_set on the doc-scan critical path. - prefetch_vector_head: prefetch only the leading cache lines of the next doc row, bounding outstanding software prefetches. - MADV_HUGEPAGE/COLLAPSE the corpus arrays to cut TLB/page-walk cost on the random per-doc gather. - sort_cluster_docs: sort doc ids ascending within each cluster so the per-doc gather is monotonic. Doc-offset precompute and two-lane AVX512 were within noise, so omitted. Add VisitedSet unit tests for the fragile sparse-reset: within-query dedup, full reset across new_query, two ids sharing a 64-bit word, resize clearing touched state, and word boundaries (63/64/n-1). Signed-off-by: Liyun Xiu <xiliyun@amazon.com>
VisitedSet indexes by doc id with no per-candidate bounds check (a branch there would erode the dedup win), so "every doc id < num_vectors" is now a load-bearing invariant: an out-of-range id is an out-of-bounds write, not the safe insert it was with the former hash set. Add InvertedListClusters::validate_doc_ids(num_docs), an O(nnz) scan that throws std::out_of_range on a stray id, called from both SeismicIndex and SeismicScalarQuantizedIndex read_index() after load. A debug assert in VisitedSet::insert documents the invariant at the point of use. Signed-off-by: Liyun Xiu <xiliyun@amazon.com>
Signed-off-by: Liyun Xiu <xiliyun@amazon.com>
|
This is the performance comparison on 46M MS Marco V2 (1/3 corpus size) |
0125691 to
7037978
Compare
GCC in the Linux CI container does not pull global ::size_t in via <cstdint>/<vector>, so every unqualified size_t use failed to compile. Signed-off-by: Liyun Xiu <xiliyun@amazon.com>
|
|
Head-prefetch seems to be the only consistent optimization But VisitedSet does earn its place — in the tail, not the mean. At k=5000 vs baseline, we could do thorough experiment with it later. will only keep head-prefetch |
Per-lever ablation on base_full (8.8M docs, 72 threads, 20 reps, Welch t-test on batch times) showed head-prefetch is the only lever with a statistically significant effect: lever k=10 k=100 head-prefetch 1.15x (t=+10.7) 1.19x (t=+21.2) VisitedSet 0.98x (t=-1.6) 1.00x (t=-0.4) hugepage 1.01x (t=+0.9) 1.00x (t=+0.1) per-cluster sort 0.98x (t=-1.6) 0.98x (t=-2.3) scratch hoisting 0.97x (t=-2.5) 1.01x (t=+0.5) All levers together gave 1.20x/1.21x, i.e. no synergy beyond prefetch alone. Drop the levers that do not pay for their complexity: the bitset VisitedSet (and its load-time doc-id validation, needed only because it indexed without bounds checks), the hugepage madvise, the per-cluster doc-id sort, and the per-thread scratch hoisting. Signed-off-by: Liyun Xiu <xiliyun@amazon.com>
Description
Done some optimization which could bring overall query performance by >30%
k = 10
k = 100
Notes
Issues Resolved
List any issues this PR will resolve, e.g. Closes [...].
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.